iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
Modern Web

重新認識Vue 走過路過不要錯過系列 第 4

Vue 走過路過不要錯過 Day04 - 有了 reactive,為什麼還要 ref?從 .value 說起

  • 分享至 

  • xImage
  •  

昨天我們自己做了一個迷你響應式,核心只有兩件事:get 的時候記錄是誰在用,set 的時候通知它們重新執行。

寫完之後,reactive 看起來已經很萬能了。那為什麼 Vue 還要多給我們一個 ref,而且每次都要多寫一個 .value

第一次寫 Vue 3 的人幾乎都問過這句:「不能全部用 reactive 就好嗎?」

今天就來回答這個問題。而且跟昨天一樣,答案不在 Vue 裡,在 JavaScript 本身。


先試試看:reactive 可以包數字嗎

直接把昨天的 reactive 拿來包一個數字:

const count = reactive(0)

先想一下會發生什麼事。

昨天的 reactive 實作是這樣:

function reactive(obj) {
  return new Proxy(obj, { /* ... */ })
}

所以這行其實等於 new Proxy(0, handler)。丟進瀏覽器跑跑看:

new Proxy(0, {})
// TypeError: Cannot create proxy with a non-object as target

直接報錯。

Proxy 的 target 必須是物件或函式,原始型別(number、string、boolean、null、undefined)一律不行。這是 JavaScript 規格層級的規定,不是 Vue 擋你。

那 Vue 真正的 reactive() 呢?它不會報錯,但也沒有幫你做什麼:

import { reactive } from 'vue'

const count = reactive(0)
// [Vue warn] value cannot be made reactive: 0
console.log(count) // 0,原封不動還給你

Vue 在開發模式下印一行警告,然後把原本的值還給你。所以 reactive(0) 不是「不建議」,是根本做不到


就算 Proxy 能包數字,還是攔不到

到這裡你可能會想:那是 Proxy 的問題,如果哪天 JS 讓 Proxy 能包數字,不就解決了嗎?

我們退一步假設它可以好了。來看這段:

let count = reactive(0)
count++

count++ 本質上是 count = count + 1

問題來了:這行改的是「count 這個變數指向誰」,不是某個物件的屬性。

昨天我們能攔截,是因為 state.count++ 動到的是 state 這個物件的 count 屬性,Proxy 的守門人站在 state 前面,看得到這個動作。

count = 1 這種變數賦值,JavaScript 沒有提供任何插手的機制。沒有 trap,沒有 hook,沒有事件。你無法知道一個變數被重新指向了別的值。

這才是真正的核心限制:

Vue 的攔截能力,完全建立在「物件的屬性存取」這件事上。沒有屬性,就沒有 get 和 set 可以攔。


換個思路:那就自己生一個屬性出來

既然只有屬性攔得到,而我們手上是一個數字,那解法其實只剩一條路:

把值塞進一個物件的屬性裡。

const count = { value: 0 }
count.value = 1  // 這下有屬性可以攔了

就這樣。ref 的整個設計動機就是這一行。

.value 不是 Vue 想讓你多打幾個字,而是把「變數賦值」硬是改寫成「屬性賦值」的唯一辦法


自己寫一個 myRef

有了方向,接著就能實作。這裡直接沿用昨天的 activeEffecteffect

let activeEffect = null

function effect(fn) {
  activeEffect = fn
  fn()
  activeEffect = null
}

然後寫 myRef

function myRef(initialValue) {
  let _value = initialValue
  const dep = new Set()   // 記錄誰用到這個值

  return {
    get value() {
      if (activeEffect) {
        dep.add(activeEffect)        // 讀取時:收集
      }
      return _value
    },
    set value(newValue) {
      if (newValue === _value) return // 值沒變就不用通知
      _value = newValue
      dep.forEach(fn => fn())         // 修改時:通知
    }
  }
}

跟昨天的 reactive 比對一下會很清楚:

reactive ref
攔截方式 Proxy 的 get / set trap 物件的 getter / setter
攔截範圍 整個物件的所有屬性 只有 .value 一個屬性
依賴存在哪 一個 key 一個 Set 只有一個 Set

因為只需要顧一個 .value,用 getter / setter 就夠了,不必開一個 Proxy。

實際用用看:

<p id="count"></p>
<button id="btn">+1</button>
const count = myRef(0)

effect(() => {
  document.querySelector('#count').textContent = count.value
})

document.querySelector('#btn').addEventListener('click', () => {
  count.value++
})

流程跟昨天一模一樣:

  1. effect 執行更新畫面的函式,函式裡讀了 count.value
  2. 讀取觸發 getter,把這個函式記進 dep
  3. 按下按鈕,count.value++ 觸發 setter
  4. setter 通知 dep 裡的函式重新執行,畫面更新

三個容易誤會的地方

一、ref 不是 Proxy

很多教學會說「ref 和 reactive 都是用 Proxy 實作的」,這不太精確。

Vue 原始碼裡的 ref 是一個叫 RefImpl 的 class,本質上就是我們上面寫的那種帶 getter / setter 的物件。因為只要追蹤一個 .value,開一個完整的 Proxy 反而更重。

二、ref 也能裝物件

ref 不是只能包原始型別。當你傳一個物件進去時,Vue 內部會用 reactive() 把它轉成 Proxy,再存進 .value

const user = ref({ name: '小明' })

user.value.name = '小華'        // 有響應式,因為 .value 裡面是 reactive
user.value = { name: '小美' }   // 整包換掉也有響應式

注意第二行。這是 reactive 做不到的事:

let state = reactive({ name: '小明' })
state = { name: '小美' }  // 響應式斷了,畫面不會更新

因為 state = ... 又回到了「變數賦值」,守門人管不到。而 ref 因為外面包了一層 .value,換掉內容物是屬性賦值,攔得到。

這就是為什麼官方建議預設就用 ref:原始型別、物件、整包替換它都能處理,reactive 則只能處理物件,而且不能整包換。

三、模板裡為什麼不用寫 .value

<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>

<template>
  <p>{{ count }}</p>
</template>

因為 <script setup> 頂層的 ref 在編譯時會自動解包。

只有頂層會。這個坑很常踩:

const obj = { count: ref(0) }
<template>
  {{ obj.count }}        <!-- 印出一個 ref 物件 -->
  {{ obj.count.value }}  <!-- 這才是 0 -->
</template>

obj 是普通物件,Vue 的自動解包不會往裡面鑽。


這不是只有你覺得困惑

如果你覺得「兩個 API 做類似的事,很難選」,這不是你的錯覺。

2021 年,尤雨溪提出了一個叫 Reactivity Transform 的實驗性提案(RFC #369),想用編譯期語法糖讓你這樣寫:

let count = $ref(0)
count++              // 編譯成 count.value++

他在提案的動機裡直接寫道:自從 Composition API 推出以來,ref 與 reactive 該怎麼選一直是主要的未解問題之一;到處寫 .value 很繁瑣,在沒有型別系統輔助時又容易漏掉,所以有些使用者乾脆只用 reactive() 來迴避 ref。

同一份文件裡,他也解釋了 .value 為什麼非存在不可:原本的寫法不需要任何編譯就能運作,但受限於 JavaScript 的運作方式,必須透過 .value 這個屬性,Vue 才能攔截它的 get / set,藉此進行依賴追蹤與觸發更新。

跟我們今天從 new Proxy(0, {}) 一路推下來的結論,完全是同一件事。


但這個提案最後被放棄了

2023 年 2 月,尤雨溪宣布團隊有共識放棄這個語法糖。理由裡有一條特別值得想一下:

拿掉 .value 之後,反而更難看出哪些東西正在被追蹤、哪一行會觸發更新。 在小組件裡不明顯,但在大型專案裡心智負擔會被放大。

其他理由還包括:有人只敢在 SFC 裡用、SFC 外不用,造成兩套心智模型;外部函式仍然需要真正的 ref 物件,轉換無可避免,反而讓初學者更困惑;以及讓變數賦值就能觸發副作用,扭曲了 JavaScript 原本的語義。

所以結論有點反直覺:

.value 看起來是缺點,但它同時是一個「這行會觸發更新」的視覺標記。

你每次寫 .value 的時候,其實是在跟未來的自己說:這裡碰到的是響應式資料。


小結

  • reactive 靠 Proxy,而 Proxy 不能包原始型別,所以 reactive(0) 做不到
  • 就算能包也沒用,因為 JS 攔不到「變數賦值」,只攔得到「屬性存取」
  • ref 的解法就是造一個 { value } 物件,把變數賦值轉成屬性賦值
  • ref 內部是 getter / setter,不是 Proxy;傳物件進去時才用 reactive 包內容物
  • ref 可以整包替換,reactive 不行,這是預設選 ref 的主因
  • <script setup> 頂層的 ref 在模板會自動解包,巢狀的不會
  • 官方試過拿掉 .value,最後發現它其實是個有用的標記

昨天的 mini reactive 還留了一個洞:它只能處理單層物件,巢狀物件完全沒管。明天 Day 5 就來補這個洞,順便談 reactive 最常見的陷阱——為什麼解構出來的變數就失去響應式了,以及 toRefs 到底在做什麼。



上一篇
Vue 走過路過不要錯過 Day03 -為什麼改資料畫面就會動?從 JS 的 Proxy 說起
下一篇
Vue 走過路過不要錯過 Day05 - 解構為什麼讓 reactive 失效?toRefs 到底在做什麼
系列文
重新認識Vue 走過路過不要錯過9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言